Skip to content

fix(host-service): name the phase in a worker task's timeout error - #6771

Merged
saddlepaddle merged 1 commit into
mainfrom
sentry/worktree-remove-phase
Aug 22, 2026
Merged

saddlepaddle merged 1 commit into
mainfrom
sentry/worktree-remove-phase

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Deleting a workspace runs git worktree remove as a worker-pool task with a
two-minute budget. On some machines the task never returns, the pool kills it,
the deletion fails, and the user retries into the same two-minute wait. The
error that reaches Sentry says only:

Failed to verify worktree removal at <path>: [host-worker] Task "git/removeWorktree" timed out after 120000ms

That is the whole report. The task does three things that stall for entirely
unrelated reasons — a blocking realpathSync.native on the worktree path, the
git worktree remove --force --force subprocess (which recursively deletes the
tree), and the git worktree list --porcelain read afterwards — and nothing
distinguishes them. The condition has been raised as needing an owner on four
consecutive triage runs and has never been diagnosable from its own telemetry.

User-visible harm: a user deleting a workspace waits two minutes, gets a
failure, and the workspace is still there; retrying costs another two minutes.
HOST-SERVICE-17 has 192 occurrences between 2026-08-10 and today on one machine
(release 1.23.0); HOST-SERVICE-47 is the same condition on a second machine in a
different organization (6 occurrences, release 1.24.1). Both are live released
builds.

This PR does not fix that hang — we still do not know what hangs, which is
the point. It makes the next occurrence say which step was in flight.

Will this reach the reported failure? Yes, and the path is exact. The Sentry
event is the TRPCError thrown at workspace-cleanup.ts:525, and its message
interpolates the pool's timeout text verbatim (it is also the trpc_message
extra). Enriching that text puts the phase in the issue title and in the event
body. The captured events carry no (inline) suffix, so they came from the
worker path — which is what this instruments; the inline fallback is covered
too. Sentry groups these by stacktrace, not message (192 events with 192
different worktree paths share one group), so the existing issues keep
collecting rather than fragmenting.

Why code, versus the alternatives? Archiving is what already happened to
HOST-SERVICE-47 (archived_until_escalating) and the condition simply came back
on another machine. The code path cannot be deleted — it is the core of workspace
deletion. An inbound Sentry filter is forbidden by repo law (#6164) and would
destroy the only signal there is. Fixing at another layer — raising the budget,
retrying, making removal more tolerant — would mask the hang without explaining
it. Doing nothing is what four triage runs already did. The missing fact exists
only inside the worker at the moment it stalls, and nothing but the worker can
observe it.

No classifier or matcher is added here.

Root cause of the blind spot

The budget is enforced by a setTimeout in the parent (WorkerTaskRunner),
while the phases run inside the worker thread. When the timer fires, the parent
knows the elapsed time and nothing else — and it immediately retires the worker,
so there is nothing left to interrogate afterwards either.

Design choice (worker reports outward, not parent reconstructs). The parent
has no way to reconstruct the phase: it holds one timer and one opaque task
payload, the handler's progress leaves no trace it can read, and the worker is
killed the instant the budget expires. The only shape that reconstructs anything
in the parent would be splitting git/removeWorktree into one pool call per
step, which would change the deletion's failure semantics — a partial removal
would become an observable intermediate state the saga has to handle — and that
is out of scope for a capture-enrichment change. So the worker announces each
step as it starts, the parent keeps the last announcement, and the timeout names
it. That is one message kind, one optional callback, and one string; there is no
progress framework here, no percentages, no subscribers, and a task that reports
nothing produces byte-identical output to before.

Fix

  • worker-task-protocol.ts: a {kind: "phase", taskId, phase} message and its
    type guard.
  • define-worker-task.ts: handlers get an optional second argument,
    reportPhase(phase). Existing handlers are untouched and ignore it.
  • host-worker.ts: binds reportPhase to the running task's id.
  • WorkerTaskRunner.ts: records the last phase per task; the timeout error
    appends in phase "<phase>" when one was reported, leaving the message
    unchanged otherwise.
  • host-worker-pool.ts: same for the inline fallback, which enforces the same
    budget and would otherwise stay undiagnosable.
  • tasks/git.ts: gitWorktreeRemoveTask reports resolve-path,
    worktree-remove, worktree-list. The first statement is labelled so no
    moment of the handler is unlabelled — an unlabelled timeout would be
    indistinguishable from one produced by a build without this change.

Nothing else changes: not the 120000ms budget, not retry behaviour, not what the
user sees, not the deletion logic.

A note on the brief: it described the middle step as "delete a leftover
directory directly". That step exists but lives in runDestroyPhases in the
parent process, outside the worker budget, so it cannot be the step that
expires. The handler's actual three steps are the ones labelled above.

Verification

All run on this branch; output pasted verbatim.

bunx biome check on the changed files:

Checked 8 files in 10ms. No fixes applied.

cd packages/host-service && bun run typecheck:

$ tsc --noEmit --emitDeclarationOnly false
TYPECHECK EXIT=0

bun test packages/host-service — the whole package, including
test/workspace-cleanup.test.ts, which #6753 also edits:

 1213 pass
 8 todo
 0 fail
Ran 1221 tests across 115 files. [213.91s]

Proof of the outcome, not an assertion of it

Four tests in host-worker-pool.test.ts shim a git onto the front of PATH
that blocks on a chosen subcommand, then drive the real
gitWorktreeRemoveTask through the real pool, worker thread and task
registry — the only fakes are git itself and the budget — and assert on the
exact message the caller receives:

(pass) HostWorkerPool > worker timeout names the phase that was running (worktree-remove)
(pass) HostWorkerPool > worker timeout names the phase that was running (worktree-list)
(pass) HostWorkerPool > a task that reports no phase keeps its timeout message unchanged
(pass) HostWorkerPool > inline timeout names the phase too

The two removeWorktree cases are the same task with the same budget and the
same shim, differing only in which subcommand stalls — so a label that tracked
the task rather than the step, or one left over from the step before, fails one
of them. The third is the other half of the contract: git/worktreeState
reports no phases, so its timeout must read exactly as it did before.

With the three reportPhase calls disabled, the three phase tests fail and the
no-phase test still passes — they are not vacuous, and the no-phase test is not
secretly coupled to the machinery:

(fail) HostWorkerPool > worker timeout names the phase that was running (worktree-remove)
(fail) HostWorkerPool > worker timeout names the phase that was running (worktree-list)
(fail) HostWorkerPool > inline timeout names the phase too
 11 pass
 3 fail

Two determinism notes, because a timing-dependent test that hangs a subprocess
is easy to get subtly wrong:

  • The worker is warmed before each timed run. A cold worker compiles the
    whole task module graph first — measured at ~305ms against a 1000ms budget on
    an idle machine — which could burn the budget before the handler ran at all,
    timing the task out having reported nothing. Warm, the first phase is reported
    within a message hop of dispatch, so the margin stops depending on machine
    load.
  • The shim blocks on a sentinel, not a sleep. A fixed sleep long enough
    to survive a loaded machine is also one left running after the test, and one
    short enough to clean up can expire mid-assertion and let the task succeed —
    a false pass. The shim instead spins until its own file is removed, which
    afterEach's existing fixture cleanup does, giving an unbounded hang with
    bounded cleanup.

The suite passes 3/3 under 16-way CPU contention as well as idle.

A fourth test in test/workspace-cleanup.test.ts closes the last link — that the
label survives into the error the reporter actually captures. It is a unit test
of the interpolation: it builds its expectation from the injected pool error
rather than restating it, so the two halves cannot drift, and the pool's exact
wording is pinned separately by the tests above.

Repo-wide bun run lint was not run — it hangs on cross-worktree bun locks.

Not covered by a test: the resolve-path label. Making realpathSync.native
block on demand is not something a test can arrange portably, so that one line
rests on inspection.


Stacked on #6753 (fix/delete-orphan-worktree-fallback), not main — that PR
edits workers/tasks/git.ts and test/workspace-cleanup.test.ts too, and this
builds on top of it without reverting or restating any of it. Review the single
commit d4b037b9a; everything below it belongs to #6753.

Note for whoever merges: #6753 is a fork PR, so its branch did not exist on
superset-sh/superset and could not be used as a base ref. The base branch here
is a mirror of #6753's head (95c5d2112) pushed to this repo purely to make the
stack expressible — delete fix/delete-orphan-worktree-fallback once #6753
merges
, and retarget this PR at main.

Both issues stay open as their own trackers. This does not resolve the hang;
it makes the next occurrence say where it hung — hence Refs, not Fixes.

Refs HOST-SERVICE-47
Refs HOST-SERVICE-17

https://claude.ai/code/session_01BrNKdKTC54LnA9jdpDDtA8


Summary by cubic

Names the in-flight phase in worker task timeout errors so triage can see which step hung. Previously the timeout named only elapsed time; now it appends the last reported phase, improving signal for git/removeWorktree timeouts (HOST-SERVICE-17, -47).

  • Adds a worker→parent phase message and guard; WorkerTaskRunner records the last phase and appends it to timeout errors; the inline path does the same.
  • Extends define-worker-task so handlers can accept an optional reportPhase(phase); existing handlers need no changes.
  • Instruments gitWorktreeRemoveTask to report resolve-path, worktree-remove, and worktree-list.
  • Keeps all other behavior unchanged: budgets, retries, deletion logic, and Sentry grouping. Tasks that report no phase keep the exact prior timeout message.

Written for commit 1216030. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added progress phase reporting for background tasks.
    • Timeout errors now identify the task phase that was active when the timeout occurred.
    • Git worktree cleanup reports progress during path resolution, removal, and verification.
  • Bug Fixes

    • Improved timeout diagnostics for both worker-based and inline task execution.
  • Tests

    • Added coverage for phase-aware timeout messages and cleanup error propagation.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 891f970c-b7bc-45e4-952c-0596d3f10dba

📥 Commits

Reviewing files that changed from the base of the PR and between a8f3fe4 and 1216030.

📒 Files selected for processing (8)
  • packages/host-service/src/workers/WorkerTaskRunner.ts
  • packages/host-service/src/workers/define-worker-task.ts
  • packages/host-service/src/workers/host-worker-pool.test.ts
  • packages/host-service/src/workers/host-worker-pool.ts
  • packages/host-service/src/workers/host-worker.ts
  • packages/host-service/src/workers/tasks/git.ts
  • packages/host-service/src/workers/worker-task-protocol.ts
  • packages/host-service/test/workspace-cleanup.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Worker task handlers can report named phases. Worker and inline execution paths retain the latest phase and include it in timeout errors. Git worktree removal reports path resolution, removal, and listing phases, with tests covering these messages.

Changes

Worker task phase reporting

Layer / File(s) Summary
Phase contract and worker emission
packages/host-service/src/workers/worker-task-protocol.ts, packages/host-service/src/workers/define-worker-task.ts, packages/host-service/src/workers/host-worker.ts
The worker protocol defines validated phase messages. Handlers accept an optional phase reporter. Workers send phase messages to the parent thread.
Timeout phase tracking
packages/host-service/src/workers/WorkerTaskRunner.ts, packages/host-service/src/workers/host-worker-pool.ts
Worker and inline execution paths record the latest phase. Timeout errors include the recorded phase when available.
Git phase reporting and validation
packages/host-service/src/workers/tasks/git.ts, packages/host-service/src/workers/host-worker-pool.test.ts, packages/host-service/test/workspace-cleanup.test.ts
Git worktree removal reports resolve-path, worktree-remove, and worktree-list. Tests verify worker, inline, unchanged, and cleanup timeout messages.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 12160

The change adds the active phase to worker timeout errors without changing deletion behavior or timeout budgets; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant GitTask as gitWorktreeRemoveTask
  participant Worker as host-worker
  participant Runner as WorkerTaskRunner
  participant Timeout as Timeout handler
  GitTask->>Worker: reportPhase("worktree-remove")
  Worker->>Runner: post phase message
  Runner->>Runner: store active task phase
  Timeout->>Runner: task budget expires
  Runner-->>Timeout: timeout error with phase
Loading

Suggested reviewers: kitenite

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 8 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses conventional commit format and clearly identifies the main change: adding the phase to worker task timeout errors.
Description check ✅ Passed The description clearly explains the problem, solution, scope, issue references, and testing results, with only minor deviations from the template headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sentry/worktree-remove-phase

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

`git/removeWorktree` blows its two-minute budget in the field
(HOST-SERVICE-17, HOST-SERVICE-47) and the only thing the report says is
that 120000ms elapsed. The task's three steps — resolving the worktree
path through a blocking realpathSync, the `worktree remove` subprocess,
and the `worktree list` read after it — stall for entirely unrelated
reasons, and nothing distinguished them.

The budget is a timer in the parent while the steps run in the worker, so
at the moment the parent gives up it knows only the elapsed time, and it
retires the worker immediately afterwards. The worker therefore announces
each step as it starts; the parent keeps the last announcement and appends
` in phase "<phase>"` to the timeout error, which is interpolated verbatim
into the TRPCError that reaches Sentry. A task that reports no phase
produces the same message as before.

This changes nothing else: not the budget, not retry behaviour, not what
the user sees, not the deletion logic. We still do not know why it hangs —
that is what this is for.

Refs HOST-SERVICE-47
Refs HOST-SERVICE-17

Claude-Session: https://claude.ai/code/session_01BrNKdKTC54LnA9jdpDDtA8
@saddlepaddle
saddlepaddle force-pushed the sentry/worktree-remove-phase branch from d4b037b to 1216030 Compare August 22, 2026 20:21
@saddlepaddle
saddlepaddle changed the base branch from fix/delete-orphan-worktree-fallback to main August 22, 2026 20:21
@saddlepaddle

Copy link
Copy Markdown
Collaborator Author

Un-stacked from #6753 and retargeted to main before merge. Recording why, since I rewrote the branch.

I originally spawned this stacked on fix/delete-orphan-worktree-fallback because #6753 is open and already edits workers/tasks/git.ts. That was the right call for avoiding a conflict, but the wrong shape for landing: #6753 is not this automation's PR, so it is not mine to merge. Rebasing this branch onto main carried #6753's two commits along with it, and merging that would have landed someone else's unreviewed work under this PR.

So I cherry-picked only this PR's own commit onto main. That conflicted in one place — the hunk's context included #6753's Log the real git error though… comment. Resolved by keeping main's version of the surrounding code and taking only the reportPhase line, so none of #6753's changes come along: .catch(() => {}) stays as it is on main, and the orphan-removal work is untouched.

Verified after un-stacking, since the point of the stack was a suspected dependency that turned out not to exist:

#6753 is unaffected and still merges on its own. When it does, it will conflict trivially in the same comment and resolve the same way.

@saddlepaddle
saddlepaddle merged commit 5fca4d7 into main Aug 22, 2026
20 checks passed
@saddlepaddle
saddlepaddle deleted the sentry/worktree-remove-phase branch August 22, 2026 20:30

This branch was previously deployed

1 inactive deployment
preview — 1216030d Deployed Aug 22, 2026 by saddlepaddle via Deploy Marketing #8308
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant